C语言里面的一些陷阱

C语言里面的陷阱非常多, 多到写了几年C之后还是会不断的踩, 首先, 本文没多少原创内容, 大部分都来自笔记整理.
我们先看几个有关Printf函数的坑:

坑: Printf(一)

// 下面一段代码的打印是什么?
int64_t a = 1;
int b = 2;
printf("%d %d",a,b);

运行一下这段代码, 发现打印出来的结果并不是期望的“1 2”, 而是“1 0”, why?
这就涉及到 printf 的设计了, printf的第一个参数是字符串, 上面代码中第一个参数是“%d %d”, printf会解析每一个%d这样的结构, 然后将指针做偏移, 偏移的字节数与%后面的类型有关, 例如%d是4字节, %lld是8字节, %c是偏移1字节.
所以, 在上面代码中printf(“%d, %d\n”, a, b)实际两个%d分别取得是 a 的低4字节和高4字节, 从而分别是1和0(测试环境是小端, 所以低地址的是LSB).

参考 @Ref

坑: Printf(二)

// 下面一段代码的打印是什么?
char c1 = 0x70; // 0111 0000
char c2 = 0xe0; // 1111 0000
printf("0x%x 0x%x",c1,c2);

打印出来的结果并不是期望的“0x70 0xe0”, 而是“70 ffffffe0”, 为什么0x70打印正常, 0xe0打印出的数值前面多了很多ffff呢?
原因是: 变参函数, 比如printf, 会把所有精度小于int的参数提升为int, char是有符号8位数, 所以被提升为4字节的int,由于c2的最高位是1(负数), 所以被提升为ffffffe0
所以, 对于用%x 格式打印下面几个char型都会被提升为int:

char    int
c0 -> ffffffc0
80 -> ffffff80
61 -> 00000061

解决方法就是用“位与”截取第8位:

char ch = 0xC0;
printf("%x", ch & 0xff);

参考 @Ref

坑: 隐式类型提升

  1. 什么是类型提升: 变量由低精度提升到高精度类型, 这个不多解释.
    整型提升: char/short/enum(无论符号)在可能的情况下会提升为int, 如果int能够完整的表示源类型的所有值, 那么就先提升为int, 如果不能则提升为unsigned int, 需要注意的一点是, 这个提升顺序和有无符号没有关系, unsigned char会先提升到int(有符号), 如果int无法完整表示源数据再尝试unsigned int.
    从上面可以得知, 整形提升可以看作有两种情况: 1是char/short提升到int, 2是int提升到unsigned int, 有符号int有时候会提升为unsigned int, 例如int型的-1(负数为补码存放1111..110), 提升为无符号int后就变成了4294967295, 最高位符号位也被当作自身的值了…
    看到下面的代码运行结果请不要惊奇:
unsigned int a = 10000;
int b = -1;
if(a < b) {
printf("1000 < -1");
}

分析: 上面的例子中的if表达式是int和unsigned int的比较,由于后者精度更高, 导致int类型的-1先被转换位unsigned int类型. 有符号的-1在内存中存储为”1000…01”, 转换为无符号整形是一个很大的数.

在 AnsiC 标准中提出的原则是,优先使用 int,并尽量保证提升后值的含义不变。也就是:如果 int 可以表达转换前的类型,则转换为 int,否则转换为 unsigned int。

在哪些情况下会产生隐式类型提升?

有些类型提升是在我们”预料之内”的, 比如char型和int型相加操, 但还有一些”隐式”的类型提升在我们的”预料之外”, 当char、short int或者int(无论signed或unsigned)以及枚举类型出现在”可以使用int或者unsigned int的表达式”中, 则会导致整形提升.

  • if里的表达式: 如果if(char)则括号里的被提升为int, 如果if(a<b)中a和b类型不一致也会自动提升为精度更高的类型.
  • 函数入参: 定义函数void func(unsigned int), 当传入参数是int时.
  • size_t类型的形参, 比如memcpy(mybuf, buf, len), 不慎把一个int型的len传入, 长度有可能转成一个很大的整数, 如果mybuf的尺寸不够大则会…

以下摘自The C Programming Language, 第一版, P39 :

在表达式中,每个 char 都被转换为 int ···注意所有位于位于表达式中的 float 都被转换为 double ···由于
函数参数也是一个表达式,所以当参数传递给函数时也会发生类型转换。具体地说, char 和 short 转换为 int, 而 float 转换为 double。

比如两个char型相加, 实际上是两个char都转换为int执行加法, 如果相加的结果要作为右值而同时左值是char类型, 则对结果进行剪裁(到char类型),如果两个char相加的结果不会溢出(即不会超过char的范围), 那么可以省略类型提升.

char a,b;
printf ( " the size of the result of a+b :%d " ,sizeof( a+b) ); //输出4

在K&R C中,由于函数的参数也是表达式,所以也会发生类型提升, 在被调用函数内部,提升后的参数被裁剪为原先声明的大小, 这就是为什么单个的printf()格式字符串%d能适用于几个不同类型,short,char或int,而不论实际传递的是上述类型的哪一个。函数从堆栈中取出的参数总是int类型,并在printf或其他或其他被调用的函数里按统一格式处理.

char c1 = 0x70; // 0111 0000
char c2 = 0xe0; // 1111 0000
printf("0x%x 0x%x",c1,c2); // 输出"70 ffffffe0"

原因是函数入参会把所有小于int的参数提升为int, char被提升为4字节的int, 由于c2的最高位是1(负数), 所以被提升为ffffffe0.
比如sizeof, sizeof返回类型是size_t, 其实就是unsigned int, 看到unsigned你又腿抖了吧.

坑: 整形(char/short/int/long)溢出

unsigned char x = 0xff;
printf("%d\n", ++x);
  • 无符号整型溢出: 对于unsigned整型溢出,C的规范是有定义的——“溢出后的数会以2^(8*sizeof(type))作模运算”,也就是说,如果一个unsigned char(1字符,8bits)溢出了,会把溢出的值与256求模。
  • 有符号整型溢出: 发生溢出后变成什么要看编译器的实现, 大部分编译器的做法是算出什么是什么.

上面的代码会输出:0 (因为0xff + 1是256,与2^8求模后就是0)

@Ref

坑: unsigned类型下溢

size_t是标准C库中定义的,在32位系统为unsigned int,在64位系统中为 long unsigned int。分别为4字节和8字节。
unsigned类型的0再做–运算,会发生什么?

//例一:
size_t num;
while(num-- > 0) {...} // 当0--时会产生下溢, 变成4294967294导致死循环
//例二:
short len = 0;
while(len< MAX_LEN) {
len += readFromInput(fd, buf);
buf += len;
}

坑: 指针和数组的区别

先看下面的代码, 在哪一行会coredown ?

#include <stdio.h>
struct str{
int len;
char s[0];
};

struct foo {
struct str *a;
};

int main(int argc, char** argv) {
struct foo f={0};
if (f.a->s) {
printf( f.a->s);
}
return 0;
}

编译一下上面的代码,在VC++和GCC下都会在14行的printf处crash掉你的程序。
把源代码中的struct str结构体中的char s[0];改成char *s;试试看,你会发现,在13行if条件的时候,程序因为Cannot access memory就直接挂掉了。
为什么声明成char s[0],程序会在14行挂掉,而声明成char *s,程序会在13行挂掉呢?那么char *s 和 char s[0]有什么差别呢?

在说明这个事之前,有必要看一下汇编代码,用GDB查看后发现:

  • 对于char s[0]来说,汇编代码用了lea指令,lea 0x04(%rax), %rdx
  • 对于char*s来说,汇编代码用了mov指令,mov 0x04(%rax), %rdx
  • lea全称load effective address,是把地址放进去,而mov则是把地址里的内容放进去。所以,就crash了。

从这里,我们可以看到,访问成员数组名其实得到的是数组的相对地址,而访问成员指针其实是相对地址里的内容(这和访问其它非指针或数组的变量是一样的)

换句话说,对于数组 char s[10]来说,数组名 s 和 &s 都是一样的(不信你可以自己写个程序试试)。在我们这个例子中,也就是说,都表示了偏移后的地址。这样,如果我们访问 指针的地址(或是成员变量的地址),那么也就不会让程序挂掉了。

结论:

  • 指针和数组的区别不仅仅是”指针p定义后可以改变其值, 而数组a[]一旦定义后无法改变a的值”;
  • 不管结构体的实例是什么——访问其成员其实就是加成员的偏移量;
  • int array[], 数组名array和&array是一样的;

@Ref: C语言结构体里的成员数组和指针

指针和数组名的区别, 用下面的代码再解释一遍:

char a[] = "hello";
char *p = "world";

  • 数组a被定义后, a即为数组名, 其值不能再改变, 而指针p的值可以改变;
  • 在代码中使用a[3]时, 直接从&a[0] 向后寻找3个字节并取出那个字节;
  • 而编译器看到p[3]时, 先生成代码找到p的位置, 取出其中的指针值, 在指针值上+3再取出该字节.
  • a[3]和p[3], 编译器解释不同, 取出字节的方式也不同. 换言之, a[3]是名为a的对象(起始位置)之后的第3个字节, p[3]是p指向的对象向后的第3个字节.

参考: